import argparse
import copy
import math
import os.path
import sys
import xml.etree.ElementTree as eT
from typing import Optional
from xml.etree import ElementTree

from basis import *
from line import Line
from subpath import Subpath
from fill import *


args = None
to_stdout = False
interpolate_step = 1
interpolate_split_threshold = 1
scale_x = 1
scale_y = 1
min_x = math.inf
min_y = math.inf
max_x = -math.inf
max_y = -math.inf
offset_amount = 0
y_step = 1
fillrule = 'nonzero'
fill_first = True


def true_y(y: float) -> float:
    return y / y_step


def cmd_params(d: list[str], idx: int, num: int):
    if idx+num > len(d):
        raise Exception(f'expected {num} params in "{d[idx:len(d)]}" at index {idx}')
    params = d[idx:idx+num]
    if any(p.isalpha() for p in params):
        raise Exception(f'expected {num} numbers in "{d[idx:idx+num]}" at index {idx}')
    return tuple([idx+num] + [float(p) for p in params])


def parse_d(d: list[str]) -> list[Subpath]:
    cpx = 0.
    cpy = 0.
    prev_ix = 0.
    prev_iy = 0.
    idx = 0
    subpath = None
    cmd = None
    cmd_u = None
    is_relative = False
    subpaths = []  # type: list[Subpath]
    while idx < len(d):
        c = d[idx]
        if c.isalpha():
            cmd = c
            cmd_u = cmd.upper()
            is_relative = cmd != cmd_u
            idx += 1
            if len(c) > 1:
                raise Exception(f'unknown command {cmd}')
        # else repeat cmd
        if cmd_u == 'M':
            if subpath is not None:
                subpath.close()
                cpx = subpath.ix
                cpy = subpath.iy
                prev_ix = subpath.ix
                prev_iy = subpath.iy
                subpaths.append(subpath)
            idx, x, y = cmd_params(d, idx, 2)
            if not is_relative:
                cpx = cpy = 0.
            cpx += x
            cpy += y
            subpath = Subpath(cpx, cpy)
            cmd_u = 'L'
            cmd = 'l' if cmd == 'm' else 'L'
        elif cmd_u == 'H':
            idx, x = cmd_params(d, idx, 1)
            subpath.h_line(is_relative, x)
        elif cmd_u == 'V':
            idx, y = cmd_params(d, idx, 1)
            subpath.v_line(is_relative, y)
        elif cmd_u == 'L':
            idx, x, y = cmd_params(d, idx, 2)
            subpath.lineto(is_relative, x, y)
        elif cmd_u == 'C':
            idx, x1, y1, x2, y2, x, y = cmd_params(d, idx, 6)
            subpath.cubic(is_relative, x1, y1, x2, y2, x, y)
        elif cmd_u == 'S':
            idx, x2, y2, x, y = cmd_params(d, idx, 4)
            subpath.cubic_smooth(is_relative, x2, y2, x, y)
        elif cmd_u == 'Q':
            idx, x1, y1, x, y = cmd_params(d, idx, 4)
            subpath.quad(is_relative, x1, y1, x, y)
        elif cmd_u == 'T':
            idx, x, y = cmd_params(d, idx, 2)
            subpath.quad_smooth(is_relative, x, y)
            pass
        elif cmd_u == 'A':
            idx, rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y = cmd_params(d, idx, 7)
            subpath.arc(is_relative, rx, ry, x_axis_rotation, large_arc_flag, sweep_flag, x, y)
        elif cmd_u == 'Z':
            subpath.close()
            cpx = subpath.ix
            cpy = subpath.iy
            prev_ix = subpath.ix
            prev_iy = subpath.iy
            subpaths.append(subpath)
            subpath = None
        else:
            raise Exception(f'unexpected command {cmd}')
        if subpath is not None and len(subpath.elements) > 0:
            x, y = subpath.elements[-1].last_point()
            if x < -1 or x > 1700 or y < -1 or y > 2800:
                print(f'warning: point {x:.2f} {y:.2f} outside svg bounds, idx={idx}, subpath={len(subpaths)}, '
                      f' initial point {prev_ix} {prev_iy}, current initial point {subpath.ix} {subpath.iy}, '
                      f'cmd={cmd}, last={subpath.elements[-1]},\n d={d[max(0, idx - 20):idx]}')
                break
    if subpath is not None:
        subpath.close()
        subpaths.append(subpath)
    global min_x, min_y
    for s in subpaths:
        x, y = s.min()
        if x < min_x:
            min_x = x
        if y < min_y:
            min_y = y
    return subpaths


# hypothesis: at each intersection point we can split intersecting elements, then split respective subpaths
#  repeating this until at most touching subpaths exist, but no intersections
# hypothesis 2: we only need to verify intersection of a subpath with itself and with each of the other subpaths.
#  there is no need to verify intersection of 3 or more paths a time.
def remove_overlaps(subpaths: list[Subpath]):
    res = []
    num_overlaps = 0
    for s in subpaths:
        # check for self intersection
        for e_idx, e in enumerate(s.elements):
            for e2_idx in range(0, e_idx):
                e2 = s.elements[e2_idx]
                if e.intersects(e2):
                    # print(f'self intersection {e} {e2}')
                    # split e at intersection
                    # split e2 at intersection
                    # add new subpaths
                    num_overlaps += 1
        res.append(s)
    print(f'found {num_overlaps} overlaps over {len(subpaths)} subpaths')
    return res


def analyze(subpaths: list[Subpath]) -> list[Subpath]:
    num_inner = 0
    num_outer = 0
    num_ignored = 0
    res = []
    for s in subpaths:
        pp = []
        y = s.elements[0].y1
        e_non_horizontal = False
        for e in s.elements:
            if y != (y + e.y1) / 2:
                y = (y + e.y1) / 2
                e_non_horizontal = True
                break
        if not e_non_horizontal:
            continue
        res.append(s)
        pp_parity_check = set()
        for s1 in subpaths:
            intersections = s1.intersections(y)
            assert s1 != s or len(intersections), f'subpath {s1.id}, y {y}, no intersections'
            for x, direction, e_idx in intersections:
                pp.append((x, direction, e_idx, s1))
                if s1 in pp_parity_check:
                    pp_parity_check.remove(s1)
                else:
                    pp_parity_check.add(s1)
        if len(pp_parity_check) > 0:
            print(f'paths {list(s1.id for s1 in pp_parity_check)} failed parity check for y {y}')
            for s1 in pp_parity_check:
                print(f' {s1.id}: {list(pp_item[0:2] for pp_item in pp if pp_item[2] == s1)}')
                s1.intersections(y, debug=True)
        pp = sorted(pp, key=lambda pp_item: pp_item[0])
        num_intersections = 0
        pp_met = set()
        for _, direction, non_horizontal_element_index, s1 in pp:
            is_outer = False
            is_inner = False
            # TODO find out if shift is needed to the left or right of the line going forward.
            #  don't care for most of lines but important for small angles
            assert direction in (-1, 1)
            if fillrule == 'nonzero':
                num_intersections += direction
                if num_intersections != 0:
                    if num_intersections - direction == 0:
                        is_outer = True
                else:
                    is_inner = True
            elif fillrule == 'evenodd':
                num_intersections = 1 - num_intersections
                if num_intersections == 1:
                    is_outer = True
                else:
                    is_inner = True
            else:
                raise Exception(f'unknown fillrule {fillrule}')
            if s1 not in pp_met:
                pp_met.add(s1)
                if s1.is_analyzed:
                    if is_outer != s1.is_outer or is_inner != s1.is_inner:
                        print(f'WARNING: subpath {s1.id} is_outer={is_outer} was {s1.is_outer}, is_inner={is_inner} was {s1.is_inner}')
                elif is_outer:
                    s1.is_outer = True
                    s1.is_analyzed = True
                    num_outer += 1
                elif is_inner:
                    s1.is_inner = True
                    s1.is_analyzed = True
                    num_inner += 1
                else:
                    num_ignored += 1
                if s1 == s:
                    assert is_outer or is_inner
                    s.start_offset_at_idx = non_horizontal_element_index
                    s.start_offset_right = is_outer == (direction == 1)
    print(f'found {num_outer} outer, {num_inner} inner, {num_ignored} ignored subpaths,'
          f' {len(subpaths)-len(res)} horizontal subpaths')
    return res


def offset_subpaths(subpaths: list[Subpath]) -> list[Subpath]:
    res = []
    for s in subpaths:
        res.append(s.offset(offset_amount))
    return res


def process_path(d: list[str], style: Optional[str]) -> tuple[Optional[str], Optional[str]]:
    if style is not None:
        global fillrule
        if 'fill-rule:evenodd' in style:
            fillrule = 'evenodd'
        else:
            fillrule = 'nonzero'

    subpaths = parse_d(d)
    if subpaths is None:
        return None, None

    subpaths = remove_overlaps(subpaths)
    subpaths = analyze(subpaths)  # remove subpaths of height 0
    subpaths_offset = offset_subpaths(subpaths)
    if args.infill_strategy == 'line':
        fill_subpaths = generate_parallel_fill(subpaths_offset, max_y, y_step, fillrule, args.no_combine_infill)
    elif args.infill_strategy == 'loop':
        fill_subpaths = generate_loop_fill(subpaths_offset, offset_amount, fillrule)
    elif args.infill_strategy == 'none':
        fill_subpaths = []
    else:
        print(f'unknown infill strategy {args.infill_strategy}')
        return None, None
    print(f'{len(fill_subpaths)} fill subpaths generated')
    if fill_first:
        subpaths_offset = fill_subpaths + subpaths_offset
    else:
        subpaths_offset.extend(fill_subpaths)

    print(f'{len(subpaths)} subpath(s) processed. fill rule {fillrule}')
    return ' '.join(s.to_d() for s in subpaths), ' '.join(s.to_d() for s in subpaths_offset)


def process(svg: ElementTree):
    namespaces = {'svg': 'http://www.w3.org/2000/svg'}
    path_list = list()
    root = svg.getroot()
    width = root.attrib.get('width')
    height = root.attrib.get('height')
    viewbox = root.attrib.get('viewBox').split(' ')
    if width is None or height is None or len(viewbox) != 4:
        print('width or height or viewBox is not found')
        return
    if viewbox[0] != '0' or viewbox[1] != '0':
        print('viewBox is not at 0,0')
        return
    if not width.endswith('mm') or not height.endswith('mm'):
        print('width or height is not in mm')
        return
    global scale_x, scale_y
    scale_x = float(viewbox[2]) / float(width[:-2])
    scale_y = float(viewbox[3]) / float(height[:-2])
    global interpolate_split_threshold
    interpolate_split_threshold = interpolate_step * (scale_x ** 2 + scale_y ** 2) ** 0.5
    Line.interpolate_split_threshold = interpolate_split_threshold
    global max_x, max_y
    max_x = float(viewbox[2])
    max_y = float(viewbox[3])
    global y_step
    y_step = (float(args.stroke_width) - (float(args.overlap) if args.overlap is not None else 0)) * scale_y
    svg_g = None
    for g in svg.findall('svg:g', namespaces):
        svg_g = g
        for path in g.findall('svg:path', namespaces):
            style = path.attrib.get('style')
            if style is not None and 'fill:' in style and 'fill:none' not in style:
                path_list.append(path)
    global offset_amount
    offset_amount = float(args.stroke_width) * scale_y / 2
    Subpath.offset_amount = offset_amount
    if not to_stdout:
        print(f'stroke width {args.stroke_width} mm {float(args.stroke_width)*scale_y:0.2f} svg units,'
              f' overlap {args.overlap if args.overlap is not None else 0} mm, {float(args.overlap if args.overlap is not None else 0)*scale_y:0.2f} svg units')
        print(f'found {len(path_list)} path element(s) to process. max_x = {max_x}, max_y = {max_y}, y_step = {y_step:0.2f}, scale_x = {scale_x:0.2f}, scale_y = {scale_y:0.2f}')
    svg_linewidth = 0.2 if args.thin_stroke else float(args.stroke_width) * scale_x
    for path in path_list:
        d = [c for c in path.attrib.get('d').replace('\n', ' ').replace(',', ' ').split(' ') if len(c) > 0]
        if d is None:
            print(f'"d" attribute is not found in path')
            continue

        d_g, d_g2 = process_path(d, path.attrib.get('style'))
        if d_g is not None:
            path.attrib['d'] = d_g
        style = path.attrib.get('style').split(';')
        style_g = []
        for s in style:
            if s.startswith('fill:'):
                style_g.append('fill:none')
            elif not s.startswith('stroke'):
                style_g.append(s)
        path.attrib['style'] = ';'.join(style_g) + f';stroke:#000000;stroke-width:{svg_linewidth}mm'
        if not args.retain_original:
            svg_g.remove(path)
        if d_g2 is not None:
            path2 = copy.deepcopy(path)
            path2.attrib['d'] = d_g2
            style2 = path2.attrib.get('style').split(';')
            style2_g = []
            for s in style2:
                if s.startswith('fill:'):
                    style2_g.append('fill:none')
                elif not s.startswith('stroke'):
                    style2_g.append(s)
            path2.attrib['style'] = ';'.join(style_g) + f';stroke:#ff0000;stroke-width:{svg_linewidth}mm' \
                                                        f';stroke-linejoin:round'
            svg_g.append(path2)


def init():
    parser = argparse.ArgumentParser(
        prog='fill2stroke',
    )
    parser.add_argument('-s', '--stroke-width', required=True,
                        help='stroke width in mm, required')
    parser.add_argument('-l', '--overlap',
                        help='required overlap of strokes')
    parser.add_argument('-o', '--output',
                        help='output file name. if not specified writes output to stdout')
    parser.add_argument('-f', '--fill-first', action='store_false', default=True, help='fill first')
    parser.add_argument('-r', '--retain-original', action='store_true', default=False, help='retain original path')
    parser.add_argument('-n', '--no-combine-infill', action='store_true', default=False,
                        help='no combine infill for filled regions, infill line by line')
    parser.add_argument('-i',  '--infill-strategy', type=str, default='none', help='one of: "none", "loop" and "line"')
    parser.add_argument('-t', '--thin-stroke', action='store_true', default=False, help='thin infill stroke')
    parser.add_argument('filename', help='input file name')
    global args
    args = parser.parse_args()
    if args.filename is None or not os.path.exists(args.filename):
        parser.print_help()
        sys.exit(1)
    global interpolate_step
    interpolate_step = float(args.stroke_width)


def main():
    init()
    namespaces = dict([node for _, node in eT.iterparse(args.filename, events=['start-ns'])])
    svg = namespaces['svg']
    del namespaces['svg']
    for ns in namespaces:
        eT.register_namespace(ns, namespaces[ns])
    eT.register_namespace('', svg)
    svg = eT.parse(args.filename)
    process(svg)
    global to_stdout
    to_stdout = args.output is None
    svg.write(args.output if args.output is not None else sys.stdout)


# Press the green button in the gutter to run the script.
if __name__ == '__main__':
    main()

